/**
 * Copyright 2012-2013 Three Crickets LLC.
 * <p>
 * The contents of this file are subject to the terms of the Apache License
 * version 2.0: http://www.opensource.org/licenses/apache2.0.php
 * <p>
 * Alternatively, you can obtain a royalty free commercial license with less
 * limitations, transferable or non-transferable, directly from Three Crickets
 * at http://threecrickets.com/
 */

package com.orientechnologies.orient.rhino;

import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;

import org.mozilla.javascript.NativeJavaObject;
import org.mozilla.javascript.ScriptRuntime;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.ScriptableObject;

import com.orientechnologies.orient.core.id.ORecordId;
import com.orientechnologies.orient.core.record.impl.ORecordBytes;
import com.orientechnologies.orient.rhino.internal.Base64;
import com.threecrickets.rhino.JsonExtender;
import com.threecrickets.rhino.util.Literal;
import com.threecrickets.rhino.util.NativeRhinoUtil;

/**
 * Support for <a href="http://www.orientdb.org/">OrientDB's extended JSON
 * format</a>.
 * 
 * @author Tal Liron
 */
public class OrientJsonExtender implements JsonExtender
{
	//
	// JsonExtender
	//

	/**
	 * Converts JavaScript objects adhering to OrientDB's extended JSON to
	 * JavaScript and OrientDB types.
	 * <p>
	 * OrientDB types: {$rid:'recordid'}, {$binary:'base64'}.
	 * <p>
	 * The {$date:timestamp} extended JSON format can be converted to either a
	 * JavaScript Date object or a java.util.Date object, according to the
	 * javaScript argument.
	 * <p>
	 * The {$float:'float'} extended JSON format is converted to a
	 * java.lang.Float object. The {$long:'integer'} extended JSON format is
	 * converted to a java.lang.Long object. The {$integer:'integer'} extended
	 * JSON format is converted to a java.lang.Integer object. The
	 * {short:'short'} extended JSON format is converted to a java.lang.Short
	 * object. The {$decimal:'decimal'} extended JSON format is converted to a
	 * java.math.BigDecimal object.
	 * 
	 * @param scriptable
	 *        The JavaScript object
	 * @param javaScript
	 *        True to convert the $date format to a JavaScript Date, otherwise
	 *        it will be converted to a java.util.Date
	 * @return An OrientDB object, a java.util.Date, a JavaScript Date or null
	 */
	public Object from( ScriptableObject scriptable, boolean javaScript )
	{
		Object floatValue = getProperty( scriptable, "$float" );
		if( floatValue != null )
		{
			// Convert extended JSON $float format to Float

			if( floatValue instanceof Number )
				return NativeRhinoUtil.wrap( ( (Number) floatValue ).floatValue() );
			else
			{
				try
				{
					return NativeRhinoUtil.wrap( Float.parseFloat( floatValue.toString() ) );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $float: " + floatValue );
				}
			}
		}

		Object longValue = getProperty( scriptable, "$long" );
		if( longValue != null )
		{
			// Convert extended JSON $long format to Long

			if( longValue instanceof Number )
				return NativeRhinoUtil.wrap( ( (Number) longValue ).longValue() );
			else
			{
				try
				{
					return NativeRhinoUtil.wrap( Long.parseLong( longValue.toString() ) );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $long: " + longValue );
				}
			}
		}

		Object integerValue = getProperty( scriptable, "$integer" );
		if( integerValue != null )
		{
			// Convert extended JSON $integer format to Integer

			if( integerValue instanceof Number )
				return NativeRhinoUtil.wrap( ( (Number) integerValue ).intValue() );
			else
			{
				try
				{
					return NativeRhinoUtil.wrap( Integer.parseInt( integerValue.toString() ) );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $integer: " + integerValue );
				}
			}
		}

		Object shortValue = getProperty( scriptable, "$short" );
		if( shortValue != null )
		{
			// Convert extended JSON $short format to Short

			if( shortValue instanceof Number )
				return NativeRhinoUtil.wrap( ( (Number) shortValue ).shortValue() );
			else
			{
				try
				{
					return NativeRhinoUtil.wrap( Short.parseShort( shortValue.toString() ) );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $short: " + shortValue );
				}
			}
		}

		Object decimalValue = getProperty( scriptable, "$decimal" );
		if( decimalValue != null )
		{
			// Convert extended JSON $decimal format to Short

			if( decimalValue instanceof Double )
				return NativeRhinoUtil.wrap( new BigDecimal( (Double) decimalValue ) );
			else if( decimalValue instanceof Long )
				return NativeRhinoUtil.wrap( new BigDecimal( (Long) decimalValue ) );
			else if( decimalValue instanceof Integer )
				return NativeRhinoUtil.wrap( new BigDecimal( (Integer) decimalValue ) );
			else
			{
				try
				{
					return NativeRhinoUtil.wrap( new BigDecimal( decimalValue.toString() ) );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $decimal: " + decimalValue );
				}
			}
		}

		Object dateValue = getProperty( scriptable, "$date" );
		if( dateValue != null )
		{
			// Convert extended JSON $date format to Rhino/JVM date

			long dateTimestamp;

			if( dateValue instanceof Number )
				dateTimestamp = ( (Number) dateValue ).longValue();
			else if( dateValue instanceof ScriptableObject )
			{
				longValue = getProperty( (ScriptableObject) dateValue, "$long" );
				if( longValue != null )
				{
					if( longValue instanceof Number )
						dateTimestamp = ( (Number) longValue ).longValue();
					else
					{
						try
						{
							dateTimestamp = Long.parseLong( longValue.toString() );
						}
						catch( NumberFormatException x )
						{
							throw new RuntimeException( "Invalid $long: " + longValue );
						}
					}
				}
				else
					throw new RuntimeException( "Invalid $date: " + dateValue );
			}
			else
			{
				try
				{
					dateTimestamp = Long.parseLong( dateValue.toString() );
				}
				catch( NumberFormatException x )
				{
					throw new RuntimeException( "Invalid $date: " + dateValue );
				}
			}

			Date date = new Date( dateTimestamp );

			if( javaScript )
				return NativeRhinoUtil.to( date );
			else
				return date;
		}

		Object rid = getProperty( scriptable, "$rid" );
		if( rid != null )
		{
			// Convert extended JSON $rid format to OrientDB ORecordId

			return new ORecordId( rid.toString() );
		}

		Object binary = getProperty( scriptable, "$binary" );
		if( binary != null )
		{
			// Convert extended JSON $binary format to OrientDB ORecordBytes

			byte[] data = Base64.decodeFast( binary.toString() );
			return new ORecordBytes( data );
		}

		return null;
	}

	/**
	 * Converts OrientDB, byte arrays, java.util.Date, java.lang.Long, and
	 * JavaScript Date objects to OrientDB's extended JSON.
	 * <p>
	 * Note that java.lang.Long will be converted only if necessary in order to
	 * preserve its value when converted to a JavaScript Number object.
	 * <p>
	 * The output can be either a native Rhino object or a java.util.HashMap.
	 * <p>
	 * A special "JavaScript" mode allows dumping JavaScript literals (for
	 * Date), though note this will break JSON compatibility!
	 * 
	 * @param object
	 * @param rhino
	 *        True to create Rhino native objects, otherwise a java.util.HashMap
	 *        will be used
	 * @param javaScript
	 *        True to allow JavaScript literals (these will break JSON
	 *        compatibility!)
	 * @return A JavaScript object, a java.util.HashMap or null if not converted
	 */
	public Object to( Object object, boolean rhino, boolean javaScript )
	{
		if( object instanceof Long )
		{
			// Convert Long to extended JSON $long format

			Long longValue = (Long) object;
			String longString = longValue.toString();

			// If the numerical value can be converted to a string via
			// JavaScript without loss of information, then there's no need to
			// convert to extended JSON

			String convertedString = ScriptRuntime.numberToString( longValue, 10 );
			if( longValue.equals( Long.valueOf( convertedString ) ) )
				return null;

			if( rhino )
			{
				Scriptable nativeObject = NativeRhinoUtil.newObject();
				ScriptableObject.putProperty( nativeObject, "$long", longString );
				return nativeObject;
			}
			else
			{
				HashMap<String, String> map = new HashMap<String, String>( 1 );
				map.put( "$long", longString );
				return map;
			}
		}
		else if( object instanceof Date )
		{
			// Convert Date to extended JSON $date format

			Scriptable timestamp = NativeRhinoUtil.wrap( ( (Date) object ).getTime() );
			if( javaScript )
			{
				return new Literal( "new Date(" + timestamp + ")" );
			}
			else if( rhino )
			{
				Scriptable nativeObject = NativeRhinoUtil.newObject();
				ScriptableObject.putProperty( nativeObject, "$date", timestamp );
				return nativeObject;
			}
			else
			{
				HashMap<String, Scriptable> map = new HashMap<String, Scriptable>( 1 );
				map.put( "$date", timestamp );
				return map;
			}
		}
		else if( object instanceof ScriptableObject )
		{
			ScriptableObject scriptable = (ScriptableObject) object;
			String className = scriptable.getClassName();
			if( className.equals( "Date" ) )
			{
				// Convert NativeDate to extended JSON $date format

				// (The NativeDate class is private in Rhino, but we can access
				// it like a regular object.)

				Object time = ScriptableObject.callMethod( scriptable, "getTime", null );
				if( time instanceof Number )
				{
					long timestamp = ( (Number) time ).longValue();
					if( rhino )
					{
						Scriptable nativeObject = NativeRhinoUtil.newObject();
						ScriptableObject.putProperty( nativeObject, "$date", timestamp );
						return nativeObject;
					}
					else
					{
						HashMap<String, Long> map = new HashMap<String, Long>( 1 );
						map.put( "$date", timestamp );
						return map;
					}
				}
			}
		}
		else if( object instanceof ORecordId )
		{
			// Convert OrientDB ORecordId to extended JSON $rid format

			String rid = ( (ORecordId) object ).toString();
			if( rhino )
			{
				Scriptable nativeObject = NativeRhinoUtil.newObject();
				ScriptableObject.putProperty( nativeObject, "$rid", rid );
				return nativeObject;
			}
			else
			{
				HashMap<String, String> map = new HashMap<String, String>( 1 );
				map.put( "$rid", ( (ORecordId) object ).toString() );
				return map;
			}
		}
		else if( object instanceof ORecordBytes )
		{
			// Convert OrientDB ORecordBytes to extended JSON $binary format

			ORecordBytes binary = (ORecordBytes) object;
			String data = Base64.encodeToString( binary.toStream(), false );
			if( rhino )
			{
				Scriptable nativeObject = NativeRhinoUtil.newObject();
				ScriptableObject.putProperty( nativeObject, "$binary", data );
				return nativeObject;
			}
			else
			{
				HashMap<String, String> map = new HashMap<String, String>( 2 );
				map.put( "$binary", data );
				return map;
			}
		}
		else if( object instanceof byte[] )
		{
			// Convert byte array to extended JSON $binary format

			byte[] bytes = (byte[]) object;
			String data = Base64.encodeToString( bytes, false );
			if( rhino )
			{
				Scriptable nativeObject = NativeRhinoUtil.newObject();
				ScriptableObject.putProperty( nativeObject, "$binary", data );
				return nativeObject;
			}
			else
			{
				HashMap<String, String> map = new HashMap<String, String>( 2 );
				map.put( "$binary", data );
				return map;
			}
		}

		return null;
	}

	// //////////////////////////////////////////////////////////////////////////
	// Private

	private static Object getProperty( ScriptableObject scriptable, String key )
	{
		Object object = ScriptableObject.getProperty( scriptable, key );
		if( object != Scriptable.NOT_FOUND )
		{
			// Unwrap
			while( object instanceof NativeJavaObject )
				object = ( (NativeJavaObject) object ).unwrap();

			return object;
		}

		return null;
	}
}
